Lietuvių

Tyrinėkite programavimo be užraktų pagrindus, daugiausia dėmesio skiriant atominėms operacijoms. Supraskite jų svarbą didelio našumo, lygiagrečioms sistemoms, pateikiant pasaulinius pavyzdžius ir praktines įžvalgas.

Programavimo be užraktų demistifikavimas: atominių operacijų galia pasauliniams programuotojams

Šiandieniniame susietame skaitmeniniame pasaulyje našumas ir mastelio keitimas yra svarbiausi. Programoms tobulėjant ir tvarkant vis didesnes apkrovas bei sudėtingus skaičiavimus, tradiciniai sinchronizavimo mechanizmai, tokie kaip „mutex“ ir semaforai, gali tapti kliūtimis. Būtent čia programavimas be užraktų iškyla kaip galinga paradigma, siūlanti kelią į labai efektyvias ir reaguojančias lygiagrečias sistemas. Programavimo be užraktų pagrindas yra fundamentali sąvoka: atominės operacijos. Šis išsamus vadovas demistifikuos programavimą be užraktų ir kritinį atominių operacijų vaidmenį programuotojams visame pasaulyje.

Kas yra programavimas be užraktų?

Programavimas be užraktų yra lygiagretumo valdymo strategija, kuri garantuoja visos sistemos progresą. Sistemoje be užraktų bent viena gija visada darys pažangą, net jei kitos gijos yra atidėtos ar sustabdytos. Tai skiriasi nuo sistemų, pagrįstų užraktais, kur gija, turinti užraktą, gali būti sustabdyta, neleidžiant jokiai kitai gijai, kuriai reikia to užrakto, tęsti darbo. Tai gali sukelti aklavietes ar „gyvas“ aklavietes (livelocks), smarkiai paveikiančias programos reagavimą.

Pagrindinis programavimo be užraktų tikslas yra išvengti konkurencijos ir galimo blokavimo, susijusio su tradiciniais užrakinimo mechanizmais. Kruopščiai kurdami algoritmus, kurie veikia su bendrinamais duomenimis be aiškių užraktų, programuotojai gali pasiekti:

Kertinis akmuo: atominės operacijos

Atominės operacijos yra pagrindas, ant kurio statomas programavimas be užraktų. Atominė operacija – tai operacija, kuri garantuotai įvykdoma visa, be pertrūkių, arba neįvykdoma išvis. Kitų gijų požiūriu atominė operacija atrodo įvykstanti akimirksniu. Šis nedalomumas yra labai svarbus norint išlaikyti duomenų nuoseklumą, kai kelios gijos vienu metu pasiekia ir modifikuoja bendrinamus duomenis.

Pagalvokite apie tai taip: jei rašote skaičių į atmintį, atominis rašymas užtikrina, kad visas skaičius bus įrašytas. Neatominis rašymas gali būti nutrauktas pusiaukelėje, paliekant iš dalies įrašytą, sugadintą reikšmę, kurią kitos gijos galėtų perskaityti. Atominės operacijos apsaugo nuo tokių lenktynių sąlygų (race conditions) labai žemame lygmenyje.

Dažniausios atominės operacijos

Nors konkretus atominių operacijų rinkinys gali skirtis priklausomai nuo aparatinės įrangos architektūrų ir programavimo kalbų, kai kurios pagrindinės operacijos yra plačiai palaikomos:

Kodėl atominės operacijos yra būtinos programavimui be užraktų?

Algoritmai be užraktų remiasi atominėmis operacijomis, kad saugiai manipuliuotų bendrinamais duomenimis be tradicinių užraktų. Palygink-ir-sukeisk (CAS) operacija yra ypač svarbi. Apsvarstykite scenarijų, kai kelioms gijoms reikia atnaujinti bendrinamą skaitiklį. Naivus požiūris galėtų apimti skaitiklio nuskaitymą, jo padidinimą ir įrašymą atgal. Ši seka yra pažeidžiama lenktynių sąlygoms:

// Neatominis didinimas (pažeidžiamas lenktynių sąlygoms)
int counter = shared_variable;
counter++;
shared_variable = counter;

Jei gija A nuskaito reikšmę 5, ir prieš jai įrašant atgal 6, gija B taip pat nuskaito 5, padidina ją iki 6 ir įrašo 6 atgal, tada gija A taip pat įrašys 6 atgal, perrašydama gijos B atnaujinimą. Skaitiklis turėtų būti 7, bet jis yra tik 6.

Naudojant CAS, operacija tampa:

// Atominis didinimas naudojant CAS
int expected_value = shared_variable.load();
int new_value;

do {
    new_value = expected_value + 1;
} while (!shared_variable.compare_exchange_weak(expected_value, new_value));

Šiame CAS pagrįstame požiūryje:

  1. Gija nuskaito dabartinę reikšmę (`expected_value`).
  2. Ji apskaičiuoja `new_value`.
  3. Ji bando sukeisti `expected_value` su `new_value` tik tuo atveju, jei reikšmė `shared_variable` vis dar yra `expected_value`.
  4. Jei sukeitimas pavyksta, operacija baigta.
  5. Jei sukeitimas nepavyksta (nes kita gija tuo tarpu modifikavo `shared_variable`), `expected_value` atnaujinama dabartine `shared_variable` reikšme, ir ciklas bando CAS operaciją iš naujo.

Šis pakartojimo ciklas užtikrina, kad didinimo operacija galiausiai pavyks, garantuojant progresą be užrakto. `compare_exchange_weak` (dažnas C++) naudojimas gali atlikti patikrinimą kelis kartus vienoje operacijoje, bet kai kuriose architektūrose gali būti efektyvesnis. Absoliučiam tikrumui vienu bandymu naudojamas `compare_exchange_strong`.

Programavimo be užraktų savybių pasiekimas

Kad algoritmas būtų laikomas tikrai veikiančiu be užraktų, jis turi atitikti šią sąlygą:

Yra susijusi sąvoka, vadinama programavimu be laukimo (wait-free), kuri yra dar stipresnė. Algoritmas be laukimo garantuoja, kad kiekviena gija baigs savo operaciją per baigtinį žingsnių skaičių, neatsižvelgiant į kitų gijų būseną. Nors tai yra idealu, algoritmus be laukimo dažnai yra žymiai sudėtingiau kurti ir įgyvendinti.

Iššūkiai programavime be užraktų

Nors nauda yra didelė, programavimas be užraktų nėra sidabrinė kulka ir turi savo iššūkių:

1. Sudėtingumas ir teisingumas

Kurti teisingus algoritmus be užraktų yra ypač sunku. Tam reikia gilaus atminties modelių, atominių operacijų ir subtilių lenktynių sąlygų, kurias gali pražiūrėti net patyrę programuotojai, supratimo. Kodo be užraktų teisingumo įrodymas dažnai apima formalius metodus arba griežtą testavimą.

2. ABA problema

ABA problema yra klasikinis iššūkis duomenų struktūrose be užraktų, ypač tose, kurios naudoja CAS. Ji atsiranda, kai reikšmė yra nuskaitoma (A), tada kita gija ją modifikuoja į B, o tada vėl modifikuoja atgal į A, prieš pirmajai gijai atliekant savo CAS operaciją. CAS operacija pavyks, nes reikšmė yra A, bet duomenys tarp pirmojo nuskaitymo ir CAS galėjo patirti reikšmingų pokyčių, vedančių prie neteisingo elgesio.

Pavyzdys:

  1. 1 gija nuskaito reikšmę A iš bendrinamo kintamojo.
  2. 2 gija pakeičia reikšmę į B.
  3. 2 gija pakeičia reikšmę atgal į A.
  4. 1 gija bando atlikti CAS su pradine reikšme A. CAS pavyksta, nes reikšmė vis dar yra A, bet tarpiniai 2 gijos atlikti pakeitimai (apie kuriuos 1 gija nežino) gali panaikinti operacijos prielaidas.

ABA problemos sprendimai paprastai apima žymėtų rodyklių (tagged pointers) arba versijų skaitiklių naudojimą. Žymėta rodyklė susieja versijos numerį (žymę) su rodykle. Kiekviena modifikacija padidina žymę. Tada CAS operacijos tikrina tiek rodyklę, tiek žymę, todėl ABA problemai pasireikšti yra daug sunkiau.

3. Atminties valdymas

Kalbomis kaip C++, rankinis atminties valdymas struktūrose be užraktų sukelia dar daugiau sudėtingumo. Kai mazgas jungtiniame sąraše be užraktų yra logiškai pašalinamas, jo negalima iš karto atlaisvinti, nes kitos gijos vis dar gali su juo dirbti, nuskaitydamos rodyklę į jį prieš tai, kai jis buvo logiškai pašalintas. Tam reikalingi sudėtingi atminties atlaisvinimo metodai, tokie kaip:

Valdomos kalbos su šiukšlių surinkimu (kaip Java ar C#) gali supaprastinti atminties valdymą, tačiau jos įneša savo sudėtingumų, susijusių su šiukšlių surinkimo pauzėmis ir jų poveikiu garantijoms be užraktų.

4. Našumo nuspėjamumas

Nors programavimas be užraktų gali pasiūlyti geresnį vidutinį našumą, atskiros operacijos gali užtrukti ilgiau dėl pakartojimų CAS cikluose. Tai gali padaryti našumą mažiau nuspėjamą, palyginti su užraktais pagrįstais metodais, kur maksimalus laukimo laikas užraktui dažnai yra apibrėžtas (nors potencialiai begalinis aklavietės atveju).

5. Derinimas ir įrankiai

Derinti kodą be užraktų yra žymiai sunkiau. Standartiniai derinimo įrankiai gali netiksliai atspindėti sistemos būseną atominių operacijų metu, o vizualizuoti vykdymo eigą gali būti sudėtinga.

Kur naudojamas programavimas be užraktų?

Tam tikrų sričių reiklūs našumo ir mastelio keitimo reikalavimai daro programavimą be užraktų nepakeičiamu įrankiu. Pasaulinių pavyzdžių gausu:

Struktūrų be užraktų įgyvendinimas: praktinis pavyzdys (konceptualus)

Apsvarstykime paprastą dėklą (stack) be užrakto, įgyvendintą naudojant CAS. Dėklas paprastai turi operacijas kaip `push` ir `pop`.

Duomenų struktūra:

struct Node {
    Value data;
    Node* next;
};

class LockFreeStack {
private:
    std::atomic head;

public:
    void push(Value val) {
        Node* newNode = new Node{val, nullptr};
        Node* oldHead;
        do {
            oldHead = head.load(); // Atomiškai nuskaityti dabartinę galvą
            newNode->next = oldHead;
            // Atomiškai bandyti nustatyti naują galvą, jei ji nepasikeitė
        } while (!head.compare_exchange_weak(oldHead, newNode));
    }

    Value pop() {
        Node* oldHead;
        Value val;
        do {
            oldHead = head.load(); // Atomiškai nuskaityti dabartinę galvą
            if (!oldHead) {
                // Dėklas tuščias, tvarkyti atitinkamai (pvz., mesti išimtį arba grąžinti žymeklį)
                throw std::runtime_error("Stack underflow");
            }
            // Bandyti sukeisti dabartinę galvą su kito mazgo rodykle
            // Jei pavyksta, oldHead rodo į mazgą, kuris yra išimamas
        } while (!head.compare_exchange_weak(oldHead, oldHead->next));

        val = oldHead->data;
        // Problema: Kaip saugiai ištrinti oldHead be ABA ar panaudojimo po atlaisvinimo?
        // Būtent čia reikalingas pažangus atminties atlaisvinimas.
        // Demonstracijos tikslais saugų ištrynimą praleisime.
        // delete oldHead; // NESAUgu TIKROJE DAUGIAGIJĖJE APLINKOJE!
        return val;
    }
};

Operacijoje `push`:

  1. Sukuriamas naujas `Node`.
  2. Atomiškai nuskaitoma dabartinė `head`.
  3. Naujo mazgo `next` rodyklė nustatoma į `oldHead`.
  4. CAS operacija bando atnaujinti `head`, kad ji rodytų į `newNode`. Jei `head` buvo modifikuota kitos gijos tarp `load` ir `compare_exchange_weak` iškvietimų, CAS nepavyksta, ir ciklas bando iš naujo.

Operacijoje `pop`:

  1. Atomiškai nuskaitoma dabartinė `head`.
  2. Jei dėklas tuščias (`oldHead` yra null), signalizuojama klaida.
  3. CAS operacija bando atnaujinti `head`, kad ji rodytų į `oldHead->next`. Jei `head` buvo modifikuota kitos gijos, CAS nepavyksta, ir ciklas bando iš naujo.
  4. Jei CAS pavyksta, `oldHead` dabar rodo į mazgą, kuris ką tik buvo pašalintas iš dėklo. Gaunami jo duomenys.

Čia trūkstama kritinė dalis yra saugus `oldHead` atlaisvinimas. Kaip minėta anksčiau, tam reikalingi sudėtingi atminties valdymo metodai, tokie kaip pavojaus rodyklės ar epocha pagrįstas atlaisvinimas, kad būtų išvengta klaidų, susijusių su panaudojimu po atlaisvinimo (use-after-free), kurios yra pagrindinis iššūkis struktūrose be užraktų su rankiniu atminties valdymu.

Tinkamo požiūrio pasirinkimas: užraktai prieš programavimą be užraktų

Sprendimas naudoti programavimą be užraktų turėtų būti pagrįstas kruopščia programos reikalavimų analize:

Geroji praktika programuojant be užraktų

Programuotojams, pradedantiems dirbti su programavimu be užraktų, verta apsvarstyti šias geriausias praktikas:

Išvada

Programavimas be užraktų, paremtas atominėmis operacijomis, siūlo sudėtingą požiūrį į didelio našumo, mastelio keitimo ir atsparių lygiagrečių sistemų kūrimą. Nors tai reikalauja gilesnio kompiuterio architektūros ir lygiagretumo valdymo supratimo, jo nauda delsai jautriose ir didelės konkurencijos aplinkose yra nepaneigiama. Pasauliniams programuotojams, dirbantiems su pažangiausiomis programomis, atominių operacijų ir dizaino be užraktų principų įvaldymas gali būti reikšmingas pranašumas, leidžiantis kurti efektyvesnius ir tvirtesnius programinės įrangos sprendimus, atitinkančius vis labiau lygiagretaus pasaulio poreikius.

Programavimo be užraktų demistifikavimas: atominių operacijų galia pasauliniams programuotojams | MLOG